feat(backend): PostHog organization groups + $groupidentify#1925
Conversation
sendEventToTracking now carries an optional `groups` field and forwards it
as `$groups` to PostHog so every server-side event is attributed to an
organization (and future group types). Previously all org-scoped events
landed on an anonymous person record keyed by the org UUID with no way to
aggregate by org in PostHog dashboards.
- utils/posthog.ts: add `$groups` to capture body; add `groupIdentifyPosthog`
helper for `$groupidentify` events.
- utils/tracking.ts: add `groups?: PostHogGroups` to the payload and
forward it to `trackPosthogEvent`. LogSnag ignores unknown fields.
- on_organization_create: `$groupidentify` the new org with name,
management_email, customer_id, created_by, created_at, website.
- stripe_event: `$groupidentify` with plan_name / plan_status / plan_type
on subscription created/updated, and plan_status=canceled on cancel.
- Pass `groups: { organization: orgId }` on every existing tracking call
across triggers and private endpoints (on_app_create,
on_version_create, on_deploy_history_create, credit_usage_alerts,
upload_link, delete_failed_version, events, plans.ts, stripe_event).
This unblocks org/plan breakdowns in PostHog dashboards (e.g. LogSnag KPI
port) without needing a backend data-warehouse join.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThis PR adds PostHog group support: a new group-identify helper, extends tracking payloads with an optional Changes
Sequence Diagram(s)sequenceDiagram
participant Trigger as Trigger (DB/Event)
participant Backend as Backend Function
participant BG as BackgroundTask runner
participant PostHog as PostHog API
Trigger->>Backend: event (e.g., org create / stripe / deploy)
Backend->>Backend: build tracking payload (includes user_id, tags, groups?)
alt groupIdentify needed
Backend->>BG: enqueue groupIdentifyPosthog(payload)
BG->>PostHog: POST $groupidentify (distinct_id: "$organization_<id>", $group_set)
end
Backend->>PostHog: POST capture (include properties and $groups when present)
PostHog-->>Backend: 200/ack
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2b318a6657
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ...trackedBody, | ||
| bento: onboardingBentoEvent, | ||
| sentToBento: Boolean(onboardingBentoEvent), | ||
| groups: trackingUserId ? { organization: trackingUserId } : undefined, |
There was a problem hiding this comment.
Use org ID before attaching PostHog organization groups
trackingUserId is not guaranteed to be an organization ID here: when user_id is omitted (common API-key flow) or is a user UUID (e.g. login tracking), resolveTrackingUserId() returns an authenticated user id, but this line still sends groups: { organization: trackingUserId }. That mislabels person-scoped events as organization-scoped in PostHog and pollutes org group analytics/cohorts with user UUIDs. Only set the organization group when you have a verified org id (requested org or app owner org).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@supabase/functions/_backend/private/events.ts`:
- Line 149: groups currently uses trackingUserId which may be a user id; replace
it with an explicit organization id variable (e.g., trackingOrgId) and set
trackingOrgId from the request/context payload field that actually holds the org
id (for example payload.organizationId, tenantId, or the owner org field where
trackingUserId is derived). Then change the groups assignment to use
trackingOrgId: groups: trackingOrgId ? { organization: trackingOrgId } :
undefined, and ensure trackingOrgId is computed alongside where trackingUserId
is set so org grouping only receives valid org ids.
In `@supabase/functions/_backend/triggers/on_organization_create.ts`:
- Around line 32-38: The properties object currently includes raw identifiers
(management_email, customer_id, created_by); remove these sensitive fields from
the org group profile and only include non-identifying segmentation data (e.g.,
name, created_at, website, plan/tier). If you must emit an identifier for
deduping or linking, replace each raw value with a pseudonymized/hash (use a
deterministic HMAC or a helper like hashIdentifier/pseudonymizeIdentifier) so
the payload contains no direct or linkable IDs; update the code that builds the
properties object (properties) to use the sanitized fields instead.
In `@supabase/functions/_backend/triggers/stripe_event.ts`:
- Around line 483-489: The groupIdentifyPosthog call is setting canceled_at to
new Date().toISOString() (worker processing time); replace that with the Stripe
event timestamp so cohorts reflect when Stripe emitted the cancellation. Use the
Stripe event's created field (e.g., event.created) or the object's canceled_at
if available, convert it to ISO (new Date(event.created * 1000).toISOString())
and pass that value into the properties.canceled_at in the
backgroundTask(groupIdentifyPosthog(...)) call, keeping the rest of the call (c,
org, etc.) unchanged.
In `@supabase/functions/_backend/utils/posthog.ts`:
- Around line 251-273: The URL construction and outbound fetch in the posthog
group-identify flow are unsafe: move the new URL(...) call inside the existing
try block (or its own try) so URL parsing errors are caught, and make the fetch
call cancellable with an AbortController + timeout (matching the `$exception`
path pattern) to avoid hanging on slow hosts; update the code around posthogUrl,
getEnv/POSTHOG_CAPTURE_URL, and the fetch invocation to use the abortable
request and ensure all errors from URL creation or fetch are routed into the
same structured failure handling used elsewhere.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 316e4b6b-1abd-4526-8d5f-ab57e8b0a5f2
📒 Files selected for processing (12)
supabase/functions/_backend/private/delete_failed_version.tssupabase/functions/_backend/private/events.tssupabase/functions/_backend/private/upload_link.tssupabase/functions/_backend/triggers/credit_usage_alerts.tssupabase/functions/_backend/triggers/on_app_create.tssupabase/functions/_backend/triggers/on_deploy_history_create.tssupabase/functions/_backend/triggers/on_organization_create.tssupabase/functions/_backend/triggers/on_version_create.tssupabase/functions/_backend/triggers/stripe_event.tssupabase/functions/_backend/utils/plans.tssupabase/functions/_backend/utils/posthog.tssupabase/functions/_backend/utils/tracking.ts
…ied org id
resolveTrackingUserId can return the authenticated user id (common API-key
flow and the /events login tracking path) when the caller omits user_id
or passes their own user UUID. The previous patch passed that id through
as `groups: { organization: trackingUserId }`, mislabeling person-scoped
events as organization-scoped and polluting the PostHog organization
group with user UUIDs.
resolveTrackingUserId now returns { trackingUserId, orgId? } — orgId is
only populated in the two branches that actually verify an organization
(app owner_org match or org.read permission). The caller forwards orgId
to PostHog groups and falls back to undefined otherwise.
Also switches the notifyConsole broadcast to use the already-verified
`requestedOrgId` for `org_id` instead of trackingUserId, since that path
is guarded by an explicit org id precondition.
|



Summary
Server-side events carry
groups: { organization: orgId }to PostHog so dashboards can break down by org/plan. Adds a newgroupIdentifyPosthoghelper and calls it on org creation + Stripe lifecycle changes to keep org properties (plan, status, etc.) fresh.Why
The LogSnag → PostHog KPI port we just did showed that backend events land on PostHog with
distinct_id = record.id(the org/app UUID), creating anonymous person records with no way to correlate to users or aggregate by org. PostHog's Groups feature is the canonical B2B fix — events attributed to a group type can be broken down, cohorted, and flagged by group properties without changing the distinct_id scheme.What changed
Plumbing
utils/posthog.ts:trackPosthogEventnow adds$groupsto the capture body when agroupsmap is supplied. New exportedgroupIdentifyPosthog(c, { groupType, groupKey, properties })posts$groupidentifyevents.utils/tracking.ts:SendEventToTrackingPayloadgains an optionalgroups?: PostHogGroupsfield;executeTrackingforwards it to PostHog. LogSnag's.track()ignores the extra field (same pattern as the existingbento/sentToBentofields).Org $groupidentify
on_organization_create— identifies the new org with{ name, management_email, customer_id, created_by, created_at, website }.stripe_event— on subscription created/updated, refreshes{ plan_name, plan_status, plan_type, subscription_status_name }; on cancel, sets{ plan_status: 'canceled', canceled_at }.Both calls run inside
backgroundTaskso they don't block the webhook response.groupspropagatedEvery existing
sendEventToTrackingcall that has anorgIdin scope now passesgroups: { organization: orgId }:on_organization_create,on_app_create,on_version_create,on_deploy_history_create,credit_usage_alerts, all 6stripe_eventpaths.upload_link,delete_failed_version,events(whentrackingUserIdis known).utils/plans.ts: all 5 plan/usage alert events.on_user_createintentionally does not passgroups(user-scope event, no org context at signup time).Out of scope (separate PR)
groups: { app: appId }) — straightforward follow-up once this lands.Test plan
bun lint:backend— cleanbun typecheck— cleanbunx vitest run tests/tracking.unit.test.ts tests/plans-onboarding-reminder.unit.test.ts— 3 tests pass$groups.organizationon the event properties (filter by$groups.organization is set).groupIdentifyPosthogcalls.organizationto confirm org-level aggregation works.Summary by CodeRabbit